/*
 * Copyright (C) 2013  Richard Schilling. All rights reserved.
 * contact: coderroadie@gmail.com
 * 
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */

package android.persist.config;

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import android.persist.PersistException;
import android.persist.annotation.DataStore;
import android.persist.annotation.DataStoreField;
import android.reflect.util.ClassReflectionUtil;
import android.reflect.util.FieldReflectionUtil;

/**
 * {@code ClassConfig} wraps a {@link Class} type and extracts information
 * needed to persist objects. All members of type {@code List<FieldConfig>} are
 * unmodifiable lists. The items of the list can be compared using object
 * references, making this statement always return true for a given instance of
 * {@code ClassConfig}:
 * <p>
 * <blockquote>
 * 
 * <pre>
 * this.fieldConfigs.get(0).field == this.fieldConfigs.get(0).field
 * </pre>
 * 
 * </blockquote>
 * <p>
 * However, the following may or may not be true depending on how the virtual
 * machine generates {@link Field} objects for a class: <blockquote>
 * 
 * <pre>
 * new ClassConfig(Foo.class).fieldConfigs.get(0) == new ClassConfig(Foo.class).fieldConfigs.get(0)
 * </pre>
 * 
 * </blockquote>
 * <p>
 * If the {@link FieldConfig} objects managed by this class were instantiated
 * each only when needed the comparison above would be unreliable because the
 * {@link Field} objects generated by {@link Class#getDeclaredFields()} and
 * {@link Class#getDeclaredField(String) can return a different instance of
 * Field every time they are called. {@code ClassConfig} solves this problem by
 * extracting {@link Field} objects only once in the constructor.
 * <p>
 * <H3>Foreign and Domestic Fields</H3>
 * </p>
 * <p>
 * A <i>domestic</i> field reference is one that can be persisted directly to
 * the class's data store. This includes Java primitives as well as a few other
 * <p>
 * A <i>domestic</i> field reference is one that can be persisted directly to
 * the class's data store. This includes Java primitives as well as a few other
 * classes such as {@link String} and {@link BigInteger}.
 * <p>
 * A <i>foreign</i> field reference is one that cannot be persisted directly to
 * the data store. In other words, any field type that is not domestic.
 * <p>
 * <h3>Requirements of a Persisted Class</h3>
 * <p>
 * All reflection exceptions are caught and thrown as a {@link PersistException}
 * . If this happens then the {@link Class} object passed to the constructor
 * needs to be re-defined in a way that allows it to be persisted.
 * <p>
 * A class can only be persisted if it meets these rules:
 * <ol>
 * <li>It must have a naked (no argument) constructor.
 * <li>{@link AccessibleObject#setAccessible(boolean) setAccessible(boolean)}
 * must not throw an exception when called on the naked constructor.
 * <li>{@link AccessibleObject#setAccessible(boolean) setAccessible(boolean)}
 * must not throw an exception when called on and any field defined in the class
 * (and its superclasses).
 * <li>If the {@link DataStore} annotation is used {@link DataStore#value()}
 * must not be null or an empty string.
 * <li>At least one field must be annotated with {@link DataStoreField#key()}.
 * </ol>
 * <p>
 * It is strongly recommended that you do not attempt to persist classes
 * distributed by the Android OS. Future versions of the API may check for these
 * classes and prohibit them from being persisted. The {@code android.persist}
 * library is intended to persist class hierarchies that you write which extend
 * {@link Object}.
 * <p>
 * <h3>ClassConfig Semantics</h3>
 * <p>
 * Here are some additional notes about how {@code ClassConfig} handles classes
 * passed to the constructor:
 * <ul>
 * <li>fields marked {@code transient} are not persisted.
 * </ul>
 * 
 * @param <T> the type of class that this configuration extracts information
 *            from.
 * @author Richard Schilling (coderroadie@gmail.com)
 * @since 1.0
 */
public final class ClassConfig<T> {

    private static final Map<String, ClassConfig<?>> sConfigMap = new Hashtable<String, ClassConfig<?>>();

    /**
     * Classes in this list can be directly stored by the persistence layer.
     * This list is used to help determine whether or not {@link #isDomestic()}
     * returns true.
     * 
     * @since 1.0
     * @see {@link #isDomestic()}
     */
    public static final Class<?>[] DIRECTLY_STORABLE = {

            String.class,
            BigDecimal.class,
            BigInteger.class,

    };

    /**
     * The {@link Class} type passed to the constructor.
     * 
     * @since 1.0
     */
    public final Class<T> clazz;

    /**
     * The list of all fields that are defined by clazz.
     * 
     * @since 1.0
     */
    public final List<FieldConfig> allFields;

    /**
     * The unique name in the data store that instances of clazz are stored
     * under. This string is internalized using {@link String#intern()} which
     * allows all storage names to be compared using a simple equals operator:
     * 
     * <pre>
     * classConfig1.storageName == classConfig2.storageName
     * </pre>
     * 
     * @since 1.0
     */
    public final String storageName;

    /**
     * The subset of {@link #allFields} that are annotated as keys. This list is
     * also a subset of {@link #domesticFields}.
     * 
     * @since 1.0
     */
    public final List<FieldConfig> keyFields;

    /**
     * The subset of {@link #allFields} that are foreign references. A foreign
     * field is one that returns {@code false} when
     * {@link FieldConfig#isDomestic()} is called. This list is mutually
     * exclusive from {@link #domesticFields}.
     * 
     * @since 1.0
     */
    public final List<FieldConfig> foreignFields;

    /**
     * The subset of {@link #allFields} that are domestic. A domestic field is
     * one that returns {@code true} when {@link FieldConfig#isDomestic()} is
     * called. This includes all primitive fields, primitive boxed fields,
     * primitive arrays, primitive boxed arrays, and classes found in
     * {@link FieldConfig#DIRECTLY_STORABLE}.
     * 
     * @since 1.0
     */
    public final List<FieldConfig> domesticFields;

    /**
     * Retrieves a {@link ClassConfig} object for the specified class. If the
     * {@link ClassConfig} object for the specified class has not been created
     * before than a new one is created and stored in a Map before being
     * returned. If it has been created before than the in-memory value from the
     * map is returned.
     * 
     * @param c the type of class to retrieve a {@link ClassConfig} object for
     * @return a unique {@link ClassConfig} object that is associated with
     *         {@code c}.
     */
    @SuppressWarnings("unchecked")
    public static <CT> ClassConfig<CT> getConfig(Class<CT> c) {
        if (c == null) {
            throw new IllegalArgumentException("c cannot be null");
        }

        String cName = c.getName().intern();
        if (sConfigMap.containsKey(cName)) {
            return (ClassConfig<CT>) sConfigMap.get(cName);
        }

        ClassConfig<CT> newConfig = new ClassConfig<CT>(c);
        sConfigMap.put(cName, newConfig);

        return newConfig;

    }

    /**
     * Creates a new object and ensures that at least one {@link Field} has a
     * {@code key} annotation.
     * 
     * @since 1.0
     * @throws IllegalArgumentException if a field in {@code c} is foreign and
     *             annotated with {@code key=true}.
     * @throws PersistException if the class cannot be persisted or doesn't have
     *             a naked constructor.
     * @throws IllegalArgumentException if the class does not have at least one
     *             field that is annotated with {@code key=true}.
     */
    private ClassConfig(Class<T> c) {

        if (ClassConfig.isDomesticClass(c)) {
            throw new IllegalArgumentException(c.getName() + " is a domestic class.");
        }

        throwIfNotPersistable(c);

        if (!isDomesticClass(c)) {
            throwIfNoBareConstructor(c);
        }

        this.clazz = c;

        this.storageName = getDatastoreName();
        this.allFields = getFieldConfigs();

        // there will be at least one key
        List<FieldConfig> tmpKeys = new ArrayList<FieldConfig>(1);
        List<FieldConfig> tmpForeign = new ArrayList<FieldConfig>();
        List<FieldConfig> tmpDomestic = new ArrayList<FieldConfig>();

        for (FieldConfig fc : this.allFields) {

            if (fc.annotation != null && fc.annotation.key()) {
                tmpKeys.add(fc);
            }

            if (fc.isDomestic()) {
                tmpDomestic.add(fc);
            } else {
                if (fc.annotation != null && fc.annotation.key()) {
                    throw new IllegalArgumentException(
                            "the key=true annotation can only be used by domestic keys");
                }
                tmpForeign.add(fc);

            }

        }

        keyFields = Collections.unmodifiableList(tmpKeys);
        foreignFields = Collections.unmodifiableList(tmpForeign);
        domesticFields = Collections.unmodifiableList(tmpDomestic);

        if (keyFields.size() == 0) {
            throw new IllegalArgumentException(
                    "class must have at least one field with a key annotation: @DataStoreField(key = true).");
        }

    }

    /**
     * Returns the unique name in the datastore that this class is persisted
     * under. This could be a table name for example. If the class is annotated
     * with the DataStoreAnnotation then this returns the value
     * {@code DataStore#value()}
     * <p>
     * For example, SQLite implementations of this API rely upon the value
     * returned from this function as the unique table name to store persisted
     * classes in.
     * 
     * @since 1.0
     * @return the name used to store {@link #clazz} in the underlying data
     *         store.
     */
    private String getDatastoreName() {
        String result = clazz.getSimpleName();

        if (clazz.isAnnotationPresent(DataStore.class)) {
            DataStore annotation = clazz.getAnnotation(DataStore.class);

            result = annotation.value();
            if (result == null || result.trim().equals("")) {
                throw new PersistException("value for annotation DataStore cannot be null or empty");
            }

        }

        return result.intern();
    }

    /**
     * Locate the naked constructor for the class. Used to ensure that there is
     * a naked constructor. If {@code clazz} is an array, then the no-arg
     * constructor of the type stored in the array is searched.
     * 
     * @since 1.0
     */
    private void throwIfNoBareConstructor(Class<?> c) {

        /*
         * if c is an array look for the no-arg constructor of the contents
         * type.
         */
        Class<?> classToCheck = c.isArray() ? c.getComponentType() : c;

        @SuppressWarnings("unchecked")
        Constructor<T>[] constructors = (Constructor<T>[]) classToCheck.getDeclaredConstructors();

        for (Constructor<T> con : constructors) {
            if (con.getParameterTypes().length == 0) {
                if (!con.isAccessible()) {
                    try {
                        con.setAccessible(true);
                        return;
                    } catch (SecurityException e) {
                        throw new PersistException(
                                "The no-arg constructor is not accessible in " + c);
                    }
                }
            }
        }

        throw new PersistException("Can't find a no-arg constructor for " + c);

    }

    /**
     * Uses reflection to iterate through a class' fields and generate
     * FieldConfig objects for each field.
     * 
     * @param clazz the class to create FieldConfig objects from
     * @return a list of FieldConfig objects.
     * @since 1.0
     */
    private List<FieldConfig> getFieldConfigs() {

        List<Field> fields = FieldReflectionUtil.getAllFields(clazz);
        List<FieldConfig> result = new ArrayList<FieldConfig>();

        for (Field f : fields) {
            int modifiers = f.getModifiers();
            if (Modifier.isTransient(modifiers) || f.getType().isEnum()
                    || Modifier.isFinal(modifiers)) {
                continue;
            }

            if (!f.isAccessible()) {
                f.setAccessible(true);
            }

            result.add(new FieldConfig(storageName, f));
        }

        return Collections.unmodifiableList(result);

    }

    /**
     * Fetches the values of all fields from an Object. All of the
     * {@link FieldConfig} objects in {@link #allFields} will be used as keys in
     * the map that is returned.
     * 
     * @param o the object to extract values from.
     * @return a map of fields and their values.
     * @since 1.0
     * @throws IllegalArgumentException if {@code o} cannot be cast to type
     *             {@link #clazz}.
     * @throws PersistException if reflection on a {@link #fieldConfig} item
     *             fails.
     */
    public Map<FieldConfig, Object> getAllFieldValues(T o) {

        return getValues(allFields, o);

    }

    /**
     * Fetches the values of all domestic fields from an Object. All of the
     * {@link FieldConfig} objects in {@link #domesticFields} will be used as
     * keys in the map that is returned.
     * 
     * @param o the object to extract values from.
     * @return a map of fields and their values.
     * @since 1.0
     * @throws IllegalArgumentException if {@code o} cannot be cast to type
     *             {@link #clazz}.
     * @throws PersistException if reflection on a {@link #fieldConfig} item
     *             fails.
     */
    public Map<FieldConfig, Object> getDomesticFieldValues(T o) {
        return getValues(domesticFields, o);
    }

    /**
     * Fetches the values of all domestic fields from an Object. All of the
     * {@link FieldConfig} objects in {@link #keyFields} will be used as keys in
     * the map that is returned.
     * 
     * @param o the object to extract values from.
     * @return a map of fields and their values.
     * @since 1.0
     * @throws IllegalArgumentException if {@code o} cannot be cast to type
     *             {@link #clazz}.
     * @throws PersistException if reflection on a {@link #fieldConfig} item
     *             fails.
     */
    public Map<FieldConfig, Object> getKeyValues(Object source) {

        if (clazz != source.getClass()) {
            throw new IllegalArgumentException("object type " + clazz.getName() + " required");
        }

        return getValues(keyFields, source);

    }

    /**
     * @see {@link #getKeyValues(Object)}
     * @see {@link #getDomesticFieldValues(Object)}
     * @see {@link #getAllFieldValues(Object)}.
     * @throws IllegalArgumentException
     * @since 1.0
     */
    private Map<FieldConfig, Object> getValues(List<FieldConfig> from, Object source) {
        if (source == null) {
            throw new IllegalArgumentException("o cannot be null");
        }

        Map<FieldConfig, Object> result = new HashMap<FieldConfig, Object>();

        for (FieldConfig fc : from) {
            try {
                result.put(fc, fc.field.get(source));
            } catch (IllegalArgumentException e) {
                throw new PersistException("unable to retrieve field value from "
                        + source.toString(), e);
            } catch (IllegalAccessException e) {
                throw new PersistException("unable to retrieve field value from "
                        + source.toString(), e);
            }
        }

        return result;
    }

    /**
     * Returns true if this class has a field in {@link #allFields} with the
     * specified name.
     * 
     * @param col the name of the field to search for
     * @return true if the field exists or false otherwise
     * @since 1.0
     */
    public boolean hasField(String fieldName) {
        for (FieldConfig fc : allFields) {
            if (fc.getPersistedName().equals(fieldName))
                return true;
        }

        return false;
    }

    /**
     * Searches object for any key fields that are null and returns each
     * {@link FieldConfig} object that has a null value.
     * 
     * @return the list of {@link FieldConfig} objects that are null in
     *         {@code object} or null if no keys have null values.
     * @param object the object to check for null keys
     * @since 1.0
     */
    public List<FieldConfig> getNullKeys(T object) {

        Map<FieldConfig, Object> keyValues = getKeyValues(object);

        // normally no keys will be null - initial capacity is 0.
        List<FieldConfig> result = new ArrayList<FieldConfig>(0);

        for (Entry<FieldConfig, Object> entry : keyValues.entrySet()) {

            if (entry.getValue() == null) {
                result.add(entry.getKey());
            }

        }

        return Collections.unmodifiableList(result);

    }

    /**
     * Returns true if this class has a field in {@link #foreignFields} with the
     * specified name.
     * 
     * @param col the name of the field to search for
     * @return true if the field exists or false otherwise
     * @since 1.0
     */
    public boolean hasForeignRef(String fieldName) {
        for (FieldConfig fc : foreignFields) {
            if (fc.getPersistedName().equals(fieldName))
                return true;
        }

        return false;

    }

    /**
     * Returns a field from {@link #foreignFields} by field name. You should
     * only call this function if use {@link #hasForeignRef(String)} returns
     * true.
     * 
     * @param foreignFieldName the name of the field to find.
     * @return the associated {@link FieldConfig}
     * @throws IllegalArgumentException if {@link #foreignFields} does not
     *             contain a field with the specified name.
     */
    public FieldConfig getForeignFieldConfig(String foreignFieldName) {
        for (FieldConfig fc : foreignFields) {
            if (fc.getPersistedName().equals(foreignFieldName))
                return fc;
        }

        throw new IllegalArgumentException("foreign field  "
                + foreignFieldName + " not found");
    }

    /**
     * Iterates through {@link #keyFields} and returns the persisted names of
     * each field.
     * 
     * @return the names of all key fields.
     * @since 1.0
     */
    public List<String> getKeyNames() {
        List<String> result = new ArrayList<String>();

        for (FieldConfig fc : keyFields) {
            result.add(fc.getPersistedName());
        }

        return result;
    }

    /**
     * Returns a field from {@link #allFields} by field name. You should only
     * call this function if use {@link #hasField(String)} returns true.
     * 
     * @param foreignFieldName the name of the field to find.
     * @return the associated {@link FieldConfig}
     * @throws IllegalArgumentException if {@link #allFields} does not contain a
     *             field with the specified name.
     * @since 1.0
     */
    public FieldConfig getFieldConfig(String fieldName) {
        for (FieldConfig fc : allFields) {
            if (fc.getPersistedName().equals(fieldName))
                return fc;
        }
        throw new IllegalArgumentException("field " + fieldName + " not found.");
    }

    /**
     * Extracts the values of all foreign reference fields from the specified
     * object.
     * 
     * @param o the object to extract foreign references from.
     * @return foreign object instances for this class and all classes this one
     *         encapsulates.
     * @throws IllegalArgumentException is object is not an instance of
     *             {@link #clazz}.
     * @since 1.0
     */
    public Map<FieldConfig, Object> getForeignFieldValues(Object o) {

        if (!clazz.isInstance(o)) {
            throw new IllegalArgumentException("o cannot be cast to this.clazz");
        }

        Map<FieldConfig, Object> result = new HashMap<FieldConfig, Object>();

        for (FieldConfig fc : foreignFields) {
            try {
                result.put(fc, fc.field.get(o));
            } catch (IllegalArgumentException e) {
                throw new PersistException("unable to retrieve field value from " + o.toString(), e);
            } catch (IllegalAccessException e) {
                throw new PersistException("unable to retrieve field value from " + o.toString(), e);
            }
        }

        return result;
    }

    /**
     * Retruns true if the specified class type is persistable
     * 
     * @param c the class to check.
     * @return true if the class can be persisted or false otherwise.
     */
    public static void throwIfNotPersistable(Class<?> c) {
        if (c.isAnnotation()) {
            throw new PersistException("persisting annotations is not supported");
        }

        if (c.isAnonymousClass()) {
            throw new PersistException("persisting anonymous classes is not supported");
        }

        if (c.isEnum()) {
            throw new PersistException("persisting enum classes is not supported.");

        }

        if (c.isInterface()) {
            throw new PersistException("interfaces cannot be persisted.");
        }

        if (c.isLocalClass()) {
            throw new PersistException("local classes cannot be persisted");
        }

        if (c.isSynthetic()) {
            throw new PersistException("persisting synthetic classes is not supported");
        }

    }

    /**
     * Returns true if this class is considered a domestic class.
     * 
     * @param c the class to check
     * @return true if the class is a domestic one or false if it's a foreign
     *         reference.
     */
    public static boolean isDomesticClass(Class<?> c) {
        if (ClassReflectionUtil.isPrimitiveOrArray(c)) {
            return true;
        }

        for (Class<?> storableClass : DIRECTLY_STORABLE) {
            if (c == storableClass) {
                return true;
            }
        }

        for (Class<?> primitiveArrayClass : ClassReflectionUtil.PRIMITIVE_ARRAYS) {
            if (c == primitiveArrayClass) {
                return true;
            }
        }

        return false;
    }

}
